parser 的第一步:分词(Tokenize)
parser 函数接收模板字符串,第一步是将其拆分为 Token 流。以 <p>Vue</p> 为例:
输入: "<p>Vue</p>"
输出 Token 流:
[
{ type: 'tag', name: 'p' }, // 开始标签
{ type: 'text', content: 'Vue' }, // 文本内容
{ type: 'tagEnd', name: 'p' }, // 结束标签
]
text
有限状态机
分词的核心是一个有限状态机(Finite State Machine),通过循环遍历字符串的每个字符,根据当前字符切换状态。
状态定义
const State = {
initial: 'initial', // 初始状态
tagOpen: 'tagOpen', // 遇到 < 后
tagName: 'tagName', // 读取标签名
text: 'text', // 读取文本
tagEnd: 'tagEnd', // 遇到 </ 后
tagEndName: 'tagEndName' // 读取结束标签名
}
typescript
状态流转过程
以 <p>Vue</p> 为例,展示完整的状态变化:
字符 状态变化 操作
───────────────────────────────────────────────────────
< initial → tagOpen 切换到标签开始状态
p tagOpen → tagName 开始记录标签名
> tagName → initial 标签名记录完成,生成 token
V initial → text 进入文本状态
u text 继续记录文本
e text 继续记录文本
< text → tagOpen 文本结束,生成 token
/ tagOpen → tagEnd 识别到结束标签
p tagEnd → tagEndName 记录结束标签名
> tagEndName → initial 生成结束标签 token
text
tokenize 实现
function tokenize(str: string): Token[] {
let currentState = State.initial
const tokens: Token[] = []
const chars: string[] = [] // 临时存储正在读取的字符
while (str) {
const char = str[0]
switch (currentState) {
case State.initial:
if (char === '<') {
currentState = State.tagOpen
str = str.slice(1) // 消费字符
} else if (isAlpha(char)) {
currentState = State.text
chars.push(char)
str = str.slice(1)
}
break
case State.tagOpen:
if (isAlpha(char)) {
currentState = State.tagName
chars.push(char)
str = str.slice(1)
} else if (char === '/') {
currentState = State.tagEnd
str = str.slice(1)
}
break
case State.tagName:
if (isAlpha(char)) {
chars.push(char)
str = str.slice(1)
} else if (char === '>') {
// 标签名读取完成
tokens.push({ type: 'tag', name: chars.join('') })
chars.length = 0 // 重置
currentState = State.initial
str = str.slice(1)
}
break
case State.text:
if (isAlpha(char)) {
chars.push(char)
str = str.slice(1)
} else if (char === '<') {
// 文本结束
tokens.push({ type: 'text', content: chars.join('') })
chars.length = 0
currentState = State.tagOpen
str = str.slice(1)
}
break
case State.tagEnd:
if (isAlpha(char)) {
currentState = State.tagEndName
chars.push(char)
str = str.slice(1)
}
break
case State.tagEndName:
if (isAlpha(char)) {
chars.push(char)
str = str.slice(1)
} else if (char === '>') {
tokens.push({ type: 'tagEnd', name: chars.join('') })
chars.length = 0
currentState = State.initial
str = str.slice(1)
}
break
}
}
return tokens
}
// 工具函数:判断是否为字母
function isAlpha(char: string): boolean {
return /[a-zA-Z]/.test(char)
}
typescript
为什么不用正则表达式
虽然正则可以匹配模板标签,但使用 while 循环 + switch 的方式更清晰地展示了状态变化的整个过程,便于理解编译器的工作原理。这也是教学演示的目的——理解状态机的概念比写正则更重要。
每个状态的处理逻辑要点
| 状态 | 触发条件 | 核心操作 |
|---|---|---|
initial | < 或字母 | < → 切换 tagOpen;字母 → 切换 text |
tagOpen | 字母或 / | 字母 → 开始记录标签名;/ → 切换 tagEnd |
tagName | 字母或 > | 字母 → 继续记录;> → 生成 token,回到 initial |
text | 字母或 < | 字母 → 继续记录;< → 生成 token,切换 tagOpen |
tagEnd | 字母 | 切换到 tagEndName 开始记录 |
tagEndName | 字母或 > | 字母 → 继续记录;> → 生成 token,回到 initial |
关键细节:消费字符
每次处理完一个字符后,必须通过 str = str.slice(1) 将其从字符串中"消费"掉,否则会陷入无限循环。这是状态机实现中常见的 bug 来源。
本节要点
- 有限状态机是 tokenizer 的核心,通过状态切换处理不同类型的字符
- 每个状态只关心当前字符,根据字符决定下一步状态
- Token 流是后续构建 AST 的基础数据
- 字符消费(slice)是避免死循环的关键
- 不使用正则的原因是为了清晰展示状态变化过程
↑